Skip to main content

Hello plu-ts

In this example prject we'll write our first smart contract and interact with it using the CIP-0030 standard.

The final verision of this project is aviable here and you can test it out in the live demo.

old version of this example project

If you are looking for the old version of this example project you can click here to check it out.

We would also highlight the useful run trough of the old example created by our community members

Pre-requisites

All we need to build a dApp is:

  • plu-ts
  • some way to submit transactions.
  • react + MeshSDK

Infact, plu-ts allows you to write the smart contract and create transactions.

To submit the tranasction we will use koios-pluts, a wrapper on top of the koios API that uses plu-ts offchain types; but we'll think about that later.

So for now our pre-requisites add up to:

  • plu-ts (and npm to install it)
  • anything that can run javascript (server environment or browser, doesn't matter)
  • an internet connection

Project set up

using git we clone the initial hello-pluts-react project form github:

git clone https://github.com/HarmonicLabs/hello-pluts-react.git
cd hello-pluts-react
git remote remove origin

this gives us the following project structure:

./hello-pluts-react
├── contracts
│ └── helloPluts.ts
├── next.config.js
├── next-env.d.ts
├── package.json
├── package-lock.json
├── Introduction
├── src
│ ├── components
│ │ └── ConnectionHandler.tsx
│ ├── offchain
│ │ ├── getTxBuilder.ts
│ │ ├── koios.ts
│ │ ├── lockTx.ts
│ │ ├── mesh-utils.ts
│ │ └── unlockTx.ts
│ ├── pages
│ │ ├── _app.tsx
│ │ ├── _document.tsx
│ │ └── index.tsx
│ └── styles
│ ├── globals.css
│ └── Home.module.css
└── tsconfig.json

The project comes with all the dependencies we need;

The most important ones are:

package.json
"dependencies": {
"@harmoniclabs/plu-ts": "^0.3.0",
"@harmoniclabs/koios-pluts": "^0.1.3",
"@harmoniclabs/uint8array-utils": "^1.0.0",
"@meshsdk/core": "^1.5.2",
"@meshsdk/react": "^1.1.7",
/* other deps */
}

So now we only need to run npm install to automatically add them.

npm install

et voilà we are ready to start!

Project overview

From the project structure we see that the code can be fund in the src folder and the contracts folder.

The contracts only file is the helloPluts.ts one; which for now contains only a contract that always fails and exports the compiled result.

contracts/helloPluts.ts
import { Address, PScriptContext, PaymentCredentials, Script, bool, compile, data, makeValidator, pBool, pfn } from "@harmoniclabs/plu-ts";


const helloPluts = pfn([
data,
data,
PScriptContext.type
], bool)
(( datum, redeemer, ctx ) => {

// locks funds forever
return pBool( false );
});

///////////////////////////////////////////////////////////////////
// ------------------------------------------------------------- //
// ------------------------- utilities ------------------------- //
// ------------------------------------------------------------- //
///////////////////////////////////////////////////////////////////

export const untypedValidator = makeValidator( helloPluts );

export const compiledContract = compile( untypedValidator );

export const script = new Script(
"PlutusScriptV2",
compiledContract
);

export const scriptTestnetAddr = new Address(
"testnet",
PaymentCredentials.script( script.hash )
);

We'll come back on this file later.

The other two files we'll work with are src/offchain/lockTx.ts and src/offchain/unlockTx.ts.

Both very similar.

src/offchain/lockTx.ts
import { Tx } from "@harmoniclabs/plu-ts";
import { BrowserWallet } from "@meshsdk/core";
import koios from "./koios";

async function getLockTx( wallet: BrowserWallet ): Promise<Tx>
{
throw "'lockTx' logic not implemented";
}

export async function lockTx( wallet: BrowserWallet): Promise<string>
{
const unsingedTx = await getLockTx( wallet );

const txStr = await wallet.signTx(
unsingedTx.toCbor().toString()
);

return (await koios.tx.submit( Tx.fromCbor( txStr ) as any )).toString();
}
src/offchain/unlockTx.ts
import { Tx } from "@harmoniclabs/plu-ts";
import { BrowserWallet } from "@meshsdk/core";
import { koios } from "./koios";

async function getUnlockTx( wallet: BrowserWallet ): Promise<Tx>
{
throw "'unlockTx' logic not implemented";
}

export async function unlockTx( wallet: BrowserWallet ): Promise<string>
{
const unsingedTx = await getUnlockTx( wallet );

const txStr = await wallet.signTx(
unsingedTx.toCbor().toString(),
true // partial sign because we have smart contracts in the transaction
);

return (await koios.tx.submit( Tx.fromCbor( txStr ) )).toString();
}

Our work later in this project will be to create the transactions that will interact with the contract.

This will be done using the plu-ts TxBuilder.

The code to get a TxBuilder instance is already defined in the src/offchain/getTxBuilder.ts

src/offchain/getTxBuilder.ts
import { ProtocolParamters, TxBuilder, defaultProtocolParameters } from "@harmoniclabs/plu-ts";
import { koios } from "./koios"

/**
* we don't want to do too many API call if we already have our `txBuilder`
*
* so after the first call we'll store a copy here.
**/
let _cachedTxBuilder: TxBuilder | undefined = undefined

export default async function getTxBuilder(): Promise<TxBuilder>
{
if(!( _cachedTxBuilder instanceof TxBuilder ))
{
try {
const pp = await koios.epoch.protocolParams() as Readonly<ProtocolParamters>;

_cachedTxBuilder = new TxBuilder(
"testnet",
pp
);
}
catch { // just in kase koios returns protocol paramters that don't look good
// if that happens then use the default protocol paramters
// !!! IMPORTANT !!! use only as fallback;
// parameters might (and will) change from the real world
_cachedTxBuilder = new TxBuilder(
"testnet",
defaultProtocolParameters
);
}
}

return _cachedTxBuilder;
}

constructing a TxBuilder requires us to pass the protocol paramters of the chain we are operating in.

why the try{ ... }catch{ ... } here?

If the protocol parameters passed are not correct (for any reason); the TxBuilder constructor throws.

In that case we can use the defaultProtocolParameters exported by plu-ts, which are always valid.

However (as the comment above explains) these paramters might not reflect reality so should be used with caution.

To get the protocol prameters we are using a koios object; this is exported from the src/offchain/koios.ts file

src/offchain/koios.ts
import { KoiosProvider } from "@harmoniclabs/koios-pluts"

export const koios = new KoiosProvider("preprod");

export default koios;

the file is really simple, and this is because we are using the koios-pluts package.

Here we construct a KoiosProvider insatance that will do the koios REST API calls for us.

Then there is the website.

This is a very simple Next.js project. of which the only page is in src/pages/index.tsx

src/pages/index.tsx
import { Button, useToast } from "@chakra-ui/react";
import { useNetwork, useWallet } from "@meshsdk/react";

import style from "@/styles/Home.module.css";
import ConnectionHandler from "@/components/ConnectionHandler";
import { lockTx } from "@/offchain/lockTx";
import { unlockTx } from "@/offchain/unlockTx";

export default function Home()
{
const { wallet, connected } = useWallet();
const network = useNetwork();
const toast = useToast();

if( typeof network === "number" && network !== 0 )
{
return (
<div className={[
style.pageContainer,
"center-child-flex-even"
].join(" ")}
>
<b style={{
margin: "auto 10vw"
}}>
Make sure to set your wallet in testnet mode;<br/>
We are playing with funds here!
</b>
<Button
onClick={() => window.location.reload()}
style={{
margin: "auto 10vw"
}}
>Refersh page</Button>
</div>
)
}

function onLock()
{
lockTx( wallet )
// lock transaction created successfully
.then( txHash => toast({
title: `lock tx submitted: https://preprod.cardanoscan.io/transaction/${txHash}`,
status: "success"
}))
// lock transaction failed
.catch( e => {
toast({
title: `something went wrong`,
status: "error"
});
console.error( e )
});
}

function onUnlock()
{
unlockTx( wallet )
// unlock transaction created successfully
.then( txHash => toast({
title: `unlock tx submitted: https://preprod.cardanoscan.io/transaction/${txHash}`,
status: "success"
}))
// unlock transaction failed
.catch( e => {
toast({
title: `something went wrong`,
status: "error"
});
console.error( e )
});
}

return (
<div className={[
style.pageContainer,
"center-child-flex-even"
].join(" ")} >
<ConnectionHandler />
{
connected &&
<>
<Button onClick={onLock} >Lock 10 tADA</Button>
<Button onClick={onUnlock} >Unlock</Button>
</>
}
</div>
)
}

all the logic of the user interface (UI) is happening here.

we are using the useNetwork react hook imported from @meshsdk/react just to prevent users to play with the contract in mainnet.

but the cool part is the useWallet hook that gives us access to the user's wallet once connected.

the connection happens in the ConnectionHandler component we defined in src/components/ConnectionHandler.ts. You can check it yourself if you want to see how easy the connection process becomes with @meshsdk/react

And with that this is it.

You can try this web applicaiton running

npm run dev

this will output somehting like the following


> hello-pluts-react@0.1.0 dev
> next dev

ready - started server on 0.0.0.0:3000, url: http://localhost:3000

the numbers may vary a little but that is not a problem.

you can check out the website going to the specified url; in the example above is http://localhost:3000.

However at this very moment the app won't do much.

So let's start by defining what the contract should do.

The contract

We want to personalize the smart contract so that:

  • it suceeds if the transaction is signed by us.
  • and we are being polite by saluting the contract.

introduce an owner

To make sure the transaction is signed by us we'll keep track of an owner in the datum (the first argument we saw in the contract).

datum

The datum helps us keep track of the history of the input the smart contract is validating.

Currently our datum is a generic data argument, but it could be really anything;

in our case all we need to keep track of is an owner

an that can be identified using a public key hash.

so in src/contract.ts we'll change the first data to PPubKeyHash

src/contract.ts
/* imports */

const helloPluts = pfn([
PPubKeyHash.type,
data,
PScriptContext.type
], bool)
// datum -> owner
(( owner, redeemer, ctx ) => {

// locks funds forever
return pBool( false );
});

/* ... */

send messages to the contracts

The second condtion requires us to send some message to the contract.

This is done thanks to the redeemer (or the second argument of a validator).

redeemer

The redeemer is the argument specified by the user that interacts with the smart contract

once again, all we need in order to have a message is just a bytestring, nothing more complex,

so we'll change the second data to the primitive type bs.

src/contract.ts
/* imports */

const helloPluts = pfn([
PPubKeyHash.type,
bs,
PScriptContext.type
], bool)
// redeemer -> message
(( owner, message, ctx ) => {

// locks funds forever
return pBool( false );
});

/* ... */

implement the logic

finally we'll check both the conditions in the body of the function.

so we'll first create a term that checks that the message is the one expected:

const isBeingPolite = message.eq("Hello plu-ts");

then we'll check that the transaction is signed by the owner specified in the datum.

to do so we need informations about the tranasaction and who signed it.

all the informations about the tranasaction are in the tx field of the PScriptContex

an in particular we are interested in the signatories field

ctx.tx.signatories;

since this is a list of all the required singers we chan use all the TermList methods; of which some allows us to check that at leat one element of the list respects a given property:

const signedByOwner = ctx.tx.signatories.some( owner.eqTerm );

and finally, we put all together

src/contract.ts
import { Address, PPubKeyHash, PScriptContext, PaymentCredentials, Script, bool, bs, compile, makeValidator, pfn } from "@harmoniclabs/plu-ts";

const helloPluts = pfn([
PPubKeyHash.type,
bs,
PScriptContext.type
], bool)
(( owner, message, ctx ) => {

const isBeingPolite = message.eq("Hello plu-ts");

const signedByOwner = ctx.tx.signatories.some( owner.eqTerm );

return isBeingPolite.and( signedByOwner );
});

/* ... */

And this is it! this is our contract.

To test it however we need to create transactions with it...

Lock some funds

In order for the smart contract to run it first needs something to spend; so now we will build a transaciton that sends some funds to the contract and provides the datum that will be used when the funds we are sending are spent.

datum

As said before the datum acts as the contract local memory.

for this reason we must set it now that we are funding the contract; as such; the datum is not provided in the spending transaction (or if it is it has to match the one that was setted in the transaction before; otherwise the transaciton is invalid)

so far what we have is this:

src/offchain/lockTx.ts
async function getLockTx( wallet: BrowserWallet ): Promise<Tx>
{
throw "'lockTx' logic not implemented";
}

myAddr

The function takes as input the wallet with which the user is connected.

the first thing we can do with the wallet is to request the user address

await wallet.getChangeAddress()

that expresions returns a string which is a cardano address in bech32 format ("addr_test1v...")

a string isn't really that useful, so we'll use that to build a plu-ts Address instance:

const myAddr = Address.fromString(
await wallet.getChangeAddress()
);

txBuilder

Since we want to build a transaciton here it might be a good idea to have a TxBuilder instance to help us.

We already have the code to create one in the src/offchain/getTxBuilder.ts file; so we just need to import it and call the function

const txBuilder = await getTxBuilder();

myUTxOs

A transaction expects as input some output of a previous tranasaction that hasnt been spent yet;

people call this stuff UTxO (Unspent Transaction Output).

The BrowserWallet turns ourselves useful once again here; we can get the wallet utxos calling

await wallet.getUtxos();

However, since this is a function defined by mesh; it is not of the type that the plu-ts TxBuilder expects.

For this reason we can map toPlutsUtxo (defined in src/offchain/mesh-utils.ts) over the result of the getUtxos call; so that myUTxOs is defined as:

myUTxOs = (await wallet.getUtxos()).map( toPlutsUtxo );

if( myUTxOs.length === 0 )
{
throw new Error("have you requested funds from the faucet?")
}
get some funds

if myUTxOs happens to be empty (length === 0) that means that your wallet has nothing in it.

If that is the case you can use the Cardano Testnet Faucet to get some testnet funds.

Just be sure to select the Preprod testnet.

return the test ADA

once you finish with your tADA make sure to return them to the faucet.

tADA have no real world value but are still limited, and onther developers will need them!

to return tADA to the faucet just send them to the following testnet address:

addr_test1qqr585tvlc7ylnqvz8pyqwauzrdu0mxag3m7q56grgmgu7sxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknswgndm3

utxo

With this transaciton we want to send 10 ADA to the contract.

so we search between the utxos we already have for one that has a little more than that (so that we can cover the tranasaction fees).

const utxo = myUTxOs.find( u => u.resolved.value.lovelaces > 15_000_000 );

if( utxo === undefined )
{
throw "not enough ada";
}

build and return the transaciton

and with all this we have:

  • an user utxo to use as input
  • the scriptTestnetAddr (imported from contracts/helloPluts.ts) which is the script address to use in the output
  • the user public key hash that we'll use as output datum

Which is all we need for our transaciton;

so we can build it using the txBuilder we have:

txBuilder.buildSync({
inputs: [{ utxo }],
outputs: [
{ // output holding the funds that we'll spend later
address: scriptTestnetAddr,
// 10M lovelaces === 10 ADA
value: Value.lovelaces( 10_000_000 ),
// remeber to include a datum
datum: new DataB(
// remember we set the datum to be the public key hash?
// we can extract it from the address as follows
myAddr.paymentCreds.hash.toBuffer()
)
}
],
// send everything left back to us
changeAddress: myAddr
});

so that the final getLockTx function should look like this:

src/offchain/lockTx.ts
async function getLockTx( wallet: BrowserWallet ): Promise<Tx>
{
// creates an address form the bech32 form
const myAddr = Address.fromString(
await wallet.getChangeAddress()
);

const txBuilder = await getTxBuilder();
const myUTxOs = (await wallet.getUtxos()).map( toPlutsUtxo );

if( myUTxOs.length === 0 )
{
throw new Error("have you requested funds from the faucet?")
}

const utxo = myUTxOs.find( u => u.resolved.value.lovelaces > 15_000_000 );

if( utxo === undefined )
{
throw "not enough ada";
}

return txBuilder.buildSync({
inputs: [{ utxo }],
outputs: [
{ // output holding the funds that we'll spend later
address: scriptTestnetAddr,
// 10M lovelaces === 10 ADA
value: Value.lovelaces( 10_000_000 ),
// remeber to include a datum
datum: new DataB(
// remember we set the datum to be the public key hash?
// we can extract it from the address as follows
myAddr.paymentCreds.hash.toBuffer()
)
}
],
// send everything left back to us
changeAddress: myAddr
});
}

test it out

with getLockTx implemented the first button on the webpage should now work.

To test it out run

npm run dev

and navigate to the specified url (eg. http://localhost:3000/);

then connect your wallet.

and click on the Lock 10 tADA button.

If everything works correctly you should be prompted to sign a transaciton.

sing transaciton pop-up

Unlock the funds

Now that the smart contract has something to spend we can really test it out.

In fact, our smart contract never ran so far.

That is because Cardano smart contracts only run if funds needs to be spent (some call them "Spending Validators").

So now we are going to do exactly that, we are going to spend some (our) smart contract funds.

the starting point is similar to before.

src/offchain/getUnlockTx.ts
async function getUnlockTx( wallet: BrowserWallet ): Promise<Tx>
{
throw "'unlockTx' logic not implemented";
}

txBuilder and myUTxOs

transaciton builder and utxos for the treansaction.

The steps are the same:

const txBuilder = await getTxBuilder();
const myUTxOs = (await wallet.getUtxos()).map( toPlutsUtxo );

myAddrs and myAddr

Before we got the user address by calling wallet.getChangeAddress.

This returns a single address of the contract. But wallet might have many; and from the moment that we are not really sure of which one we'll get, this time we'll select it manually.

to get all the address of a wallet we can call

await wallet.getUsedAddresses()

just like before the method returns strings; so we'll need to call Address.fromString for each of them.

This way we can get all the wallet addresses as follows:

const myAddrs = (await wallet.getUsedAddresses()).map( Address.fromString );

from these we'll decide which one will be used in the next step.

For now lets just decalre a variable for it.

let myAddr!: Address;

get the script's utxos

Script utxos are a bit trickier... scripts don't have wallets!

So to retreive the script's utxos we'll rely on koios; the code to query them is:

await koios.address.utxos( scriptTestnetAddr )

where scriptTestnetAddr is imported from contracts/helloPluts.ts.

but a script might (and will) have multiple utxos... how do we know which one is our?

for that we'll search between them using the following funciton

const utxoToSpend = (await koios.address.utxos( scriptTestnetAddr ))
.find( utxo => {
const datum = utxo.resolved.datum;

if(
// datum is inline
isData( datum ) &&
// and is only bytes
datum instanceof DataB
)
{
const pkh = datum.bytes.toBuffer();

// search if it corresponds to one of my public keys
const myPkhIdx = myAddrs.findIndex(
addr => uint8ArrayEq( pkh, addr.paymentCreds.hash.toBuffer() )
);

// not a pkh of mine; not an utxo I can unlock
if( myPkhIdx < 0 ) return false;

// else found my locked utxo
myAddr = myAddrs[ myPkhIdx ];

return true;
}

return false;
});

given an utxo we first need to check that it has an inline datum and that the datum is just made of bytes.

once we know the datum is in the correct format we extract the setted public key hash that represents that utxo owner:

const pkh = datum.bytes.toBuffer();

and finally we check for the owner to be equal to any of the addresses in control of the wallet; if any address matches the we found the address to be used and the utxo to be spent; otherwise we check an other utxo.

after the filter call myAddr will then be defined.

build the transaciton

now we have everything needed; we can build the unlocking transaciton:

txBuilder.buildSync({
inputs: [
{
utxo: utxoToSpend as any,
// we must include the utxo that holds our script
inputScript: {
script,
datum: "inline", // the datum is present already on `utxoToSpend`
redeemer: new DataB( fromAscii("Hello plu-ts") ) // be polite
}
}
],
requiredSigners: [ myAddr.paymentCreds.hash ],
// make sure to include collateral when using contracts
collaterals: [ myUTxOs[0] ],
// send everything back to us
changeAddress: myAddr
});

this time the transaciton is a bit more complicated so let's check what's new.

First of all the input now comes from a script! For this reason we have to include the script in alongside the utxo to be spent for the script to validate it.

this is done trough the inputScript option:

inputScript: {
script,
datum: "inline", // the datum is present already on `utxoToSpend`
redeemer: new DataB( fromAscii("Hello plu-ts") ) // be polite
}

the script is the one imported from contracts/helloPluts.ts.

datum is set to be "inline" which means that the datum is already present on the UTxO being spent.

finally redeemer is used to pass the redeemer to call the contract with.

redeemer

unlike the datum, the redeemer is used to pass data to the script at the moment of calling it.

this can be used as a way to comunicate between the user and the contract

The onther important part of the tranasaction is the requiredSigners option.

Here we have to specify the our public key hash so that it can be included int the signatories field of the context passed to the contract. If we forget it the ctx.tx.signatories value in our smart contract will be empty!

and finally, the third news is the collaterals option; this can be any utxo we own as long as it contains only ADA; if it includes any other token it must be returned using the collateralReturn option; but this is not our case.

So our final getUnlockTx funciton looks like this.

src/offchain/getUnlockTx.ts
async function getUnlockTx( wallet: BrowserWallet ): Promise<Tx>
{
const myAddrs = (await wallet.getUsedAddresses()).map( Address.fromString );

const txBuilder = await getTxBuilder();
const myUTxOs = (await wallet.getUtxos()).map( toPlutsUtxo );

/**
* Wallets migh have multiple addresses;
*
* to understand which one we previously used to lock founds
* we'll get the address based on the utxo that keeps one of ours
* public key hash as datum
**/
let myAddr!: Address;

// only the onses with valid datum
const utxoToSpend = (await koios.address.utxos( scriptTestnetAddr ))
.find( utxo => {
const datum = utxo.resolved.datum;

if(
// datum is inline
isData( datum ) &&
// and is only bytes
datum instanceof DataB
)
{
const pkh = datum.bytes.toBuffer();

// search if it corresponds to one of my public keys
const myPkhIdx = myAddrs.findIndex(
addr => uint8ArrayEq( pkh, addr.paymentCreds.hash.toBuffer() )
);

// not a pkh of mine; not an utxo I can unlock
if( myPkhIdx < 0 ) return false;

// else found my locked utxo
myAddr = myAddrs[ myPkhIdx ];

return true;
}

return false;
});

if( utxoToSpend === undefined )
{
throw "uopsie, are you sure your tx had enough time to get to the blockchain?"
}

return txBuilder.buildSync({
inputs: [
{
utxo: utxoToSpend as any,
// we must include the utxo that holds our script
inputScript: {
script,
datum: "inline", // the datum is present already on `utxoToSpend`
redeemer: new DataB( fromAscii("Hello plu-ts") ) // be polite
}
}
],
requiredSigners: [ myAddr.paymentCreds.hash ],
// make sure to include collateral when using contracts
collaterals: [ myUTxOs[0] ],
// send everything back to us
changeAddress: myAddr
});
}

test the contract

Now our applicaiton is complete.

We just need to test out the last feature introduced.

once again spin up the web page and this time let's click on the second button.

unlock tx pop-up

smart contract determinism

did you know that smart contract on cardano are deterministic?

it means that when you run them with the same inputs you always get the same output.

For this reason the plu-ts TxBuilder is able to pre-evaluate your transaciton and will throw an error if the script fails!

that means that if you are signing a transaciton built wit plu-ts you can be sure there are no surprises!

here is an example of suceeding transaciton:

https://preprod.cardanoscan.io/transaction/3d04a8bb90c3d6edb765439f3ec370053b2904841648ba64281c0680a73226fa